This repository has no description
1import type { Did } from "@atcute/lexicons/syntax";
2import { createBobbinClient } from "$lib/api/client";
3import { fetchPage, items } from "$lib/api/pagination";
4import { count } from "$lib/api/count";
5import { getRepoByRepoDid, type RepoRecord } from "$lib/api/records";
6import { IdentityCache } from "$lib/api/identity";
7import { didFromUri, rkeyFromUri } from "$lib/api/uri";
8import { toHttpError, parallel } from "$lib/api/load";
9import { search } from "$lib/api/search";
10import type { BobbinContext } from "$lib/api/client";
11import { listStarRkeys, type VouchRecord, type FollowRecord } from "$lib/api/graph";
12import type * as ShTangledFeedStar from "$lib/api/lexicons/types/sh/tangled/feed/star";
13import type * as ShTangledString from "$lib/api/lexicons/types/sh/tangled/string";
14import type * as ShTangledGraphFollow from "$lib/api/lexicons/types/sh/tangled/graph/follow";
15import type {
16 RepoCardData,
17 StringCardData,
18 PersonData,
19 VouchData,
20 StarData
21} from "$lib/components/profile/types";
22import type { PageLoad } from "./$types";
23
24const PAGE_LIMIT = 50;
25
26const TABS = [
27 "overview",
28 "repos",
29 "starred",
30 "strings",
31 "followers",
32 "following",
33 "vouches"
34] as const;
35type Tab = (typeof TABS)[number];
36
37const normalizeTab = (raw: string | null): Tab =>
38 TABS.includes(raw as Tab) ? (raw as Tab) : "overview";
39
40interface ListItem {
41 uri: string;
42 value: unknown;
43}
44
45const toRepoCard = (item: ListItem, ownerHandle: string): RepoCardData => {
46 const value = item.value as RepoRecord;
47 return {
48 rkey: rkeyFromUri(item.uri),
49 name: value.name ?? rkeyFromUri(item.uri),
50 repoDid: value.repoDid ?? "",
51 ownerHandle,
52 description: value.description,
53 knot: value.knot,
54 createdAt: value.createdAt
55 };
56};
57
58interface ResolveRepoCardOptions {
59 viewerStarRkeys?: ReadonlyMap<string, string>;
60}
61
62const resolveRepoCard = async (
63 ctx: BobbinContext,
64 item: ListItem,
65 ownerHandle: string,
66 options: ResolveRepoCardOptions = {}
67): Promise<RepoCardData> => {
68 const repo = toRepoCard(item, ownerHandle);
69 if (!repo.repoDid) return { ...repo, stars: 0, viewerStarRkey: null };
70 // TODO(bobbin): instead of doing this, listing repos should return star counts
71 // and most likely other stats as well.
72 const stars = await count(ctx, "sh.tangled.feed.countStars", repo.repoDid);
73 return {
74 ...repo,
75 stars: stars.count,
76 viewerStarRkey: options.viewerStarRkeys
77 ? (options.viewerStarRkeys.get(repo.repoDid) ?? null)
78 : undefined
79 };
80};
81
82const toStringCard = (item: ListItem, ownerHandle: string): StringCardData => {
83 const value = item.value as ShTangledString.Main;
84 return {
85 rkey: rkeyFromUri(item.uri),
86 ownerHandle,
87 filename: value.filename,
88 description: value.description,
89 createdAt: value.createdAt,
90 lines: value.contents?.split("\n").length ?? 1
91 };
92};
93
94// resolve dids -> handle/avatar, deduped, preserving input order.
95const resolvePeople = async (
96 ctx: BobbinContext,
97 dids: string[],
98 viewerDid?: string
99): Promise<PersonData[]> => {
100 const cache = new IdentityCache(ctx);
101 const unique = [...new Set(dids)];
102
103 const docs = await Promise.all(unique.map((did) => cache.resolve(did).catch(() => null)));
104 // TODO(bobbin): need bobbin to return follower / following stats when listing follows..
105 const counts = await parallel(
106 unique.reduce(
107 (acc, did) => {
108 acc[`${did}-followers`] = count(ctx, "sh.tangled.graph.countFollows", did)
109 .then((result) => result.count)
110 .catch(() => 0);
111 acc[`${did}-following`] = count(ctx, "sh.tangled.graph.countFollowsBy", did)
112 .then((result) => result.count)
113 .catch(() => 0);
114 return acc;
115 },
116 {} as Record<string, Promise<number>>
117 )
118 );
119
120 const viewerFollowRkeys = new Map<string, string>();
121 if (viewerDid) {
122 for await (const item of items(
123 ctx,
124 "sh.tangled.graph.listFollowsBy",
125 { subject: viewerDid as Did },
126 { maxPages: 10 }
127 )) {
128 const value = item.value as FollowRecord;
129 viewerFollowRkeys.set(value.subject, rkeyFromUri(item.uri));
130 }
131 }
132
133 const byDid = new Map<string, PersonData>();
134 unique.forEach((did, index) => {
135 const doc = docs[index];
136 const followers = counts[`${did}-followers`];
137 const following = counts[`${did}-following`];
138 const isSelf = viewerDid === did;
139 const viewerFollowRkey = viewerDid ? (viewerFollowRkeys.get(did) ?? null) : undefined;
140 byDid.set(
141 did,
142 doc
143 ? {
144 did: doc.did,
145 handle: doc.handle,
146 avatar: doc.avatar,
147 followers,
148 following,
149 isSelf,
150 viewerFollowRkey
151 }
152 : { did, handle: did, followers, following, isSelf, viewerFollowRkey }
153 );
154 });
155 return unique.map((did) => byDid.get(did) as PersonData);
156};
157
158const resolveVouches = async (
159 ctx: BobbinContext,
160 items: ListItem[],
161 direction: "incoming" | "outgoing"
162): Promise<VouchData[]> => {
163 const cache = new IdentityCache(ctx);
164 return Promise.all(
165 items.map(async (item): Promise<VouchData> => {
166 const value = item.value as VouchRecord;
167 const otherDid = direction === "incoming" ? didFromUri(item.uri) : rkeyFromUri(item.uri);
168 const doc = await cache.resolve(otherDid).catch(() => null);
169 return {
170 uri: item.uri,
171 did: otherDid,
172 handle: doc?.handle ?? otherDid,
173 avatar: doc?.avatar,
174 kind: value.kind === "denounce" ? "denounce" : "vouch",
175 direction,
176 reason: value.reason,
177 createdAt: value.createdAt
178 };
179 })
180 );
181};
182
183const resolveStars = async (
184 ctx: BobbinContext,
185 items: ListItem[],
186 options: ResolveRepoCardOptions
187): Promise<StarData[]> => {
188 const cache = new IdentityCache(ctx);
189 const resolved = await Promise.all(
190 items.map(async (item): Promise<StarData | null> => {
191 const value = item.value as ShTangledFeedStar.Main;
192 const subject = value.subject;
193 if (subject && "did" in subject && subject.did) {
194 try {
195 const repo = await getRepoByRepoDid(ctx, subject.did);
196 const ownerDid = didFromUri(repo.uri);
197 const owner = await cache.resolve(ownerDid).catch(() => null);
198 return {
199 kind: "repo",
200 uri: item.uri,
201 createdAt: value.createdAt,
202 repo: await resolveRepoCard(ctx, repo, owner?.handle ?? ownerDid, options)
203 };
204 } catch {
205 return null;
206 }
207 }
208 if (subject && "uri" in subject && subject.uri) {
209 const ownerDid = didFromUri(subject.uri);
210 const owner = await cache.resolve(ownerDid).catch(() => null);
211 return {
212 kind: "string",
213 uri: item.uri,
214 createdAt: value.createdAt,
215 ownerHandle: owner?.handle ?? ownerDid,
216 rkey: rkeyFromUri(subject.uri)
217 };
218 }
219 return null;
220 })
221 );
222 return resolved.filter((star): star is StarData => star !== null);
223};
224
225export const load: PageLoad = async (event) => {
226 const parent = await event.parent();
227 const tab = normalizeTab(event.url.searchParams.get("tab"));
228
229 if (parent.notJoined) return { tab: "overview" as const, overview: { pinned: [] } };
230
231 const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch });
232 const did = parent.identity.did as Did;
233 const handle = parent.identity.handle;
234
235 try {
236 switch (tab) {
237 case "repos": {
238 const q = event.url.searchParams.get("q")?.trim();
239 const [found, viewerStarRkeys] = await Promise.all([
240 q
241 ? search(ctx, { q, nsid: "sh.tangled.repo", author: did, limit: PAGE_LIMIT }).then(
242 (page) => page.hits
243 )
244 : fetchPage(ctx, "sh.tangled.repo.listRepos", { subject: did, limit: PAGE_LIMIT }).then(
245 (page) => page.items
246 ),
247 parent.auth?.did ? listStarRkeys(ctx, parent.auth.did) : undefined
248 ]);
249 return {
250 tab,
251 repos: await Promise.all(
252 found.map((item) => resolveRepoCard(ctx, item, handle, { viewerStarRkeys }))
253 )
254 };
255 }
256 case "strings": {
257 const page = await fetchPage(ctx, "sh.tangled.string.listStrings", {
258 subject: did,
259 limit: PAGE_LIMIT
260 });
261 return { tab, strings: page.items.map((item) => toStringCard(item, handle)) };
262 }
263 case "followers": {
264 const page = await fetchPage(ctx, "sh.tangled.graph.listFollows", {
265 subject: did,
266 limit: PAGE_LIMIT
267 });
268 const dids = page.items.map((item) => didFromUri(item.uri));
269 return {
270 tab,
271 people: await resolvePeople(ctx, dids, parent.auth?.did)
272 };
273 }
274 case "following": {
275 const page = await fetchPage(ctx, "sh.tangled.graph.listFollowsBy", {
276 subject: did,
277 limit: PAGE_LIMIT
278 });
279 const dids = page.items.map((item) => (item.value as ShTangledGraphFollow.Main).subject);
280 return {
281 tab,
282 people: await resolvePeople(ctx, dids, parent.auth?.did)
283 };
284 }
285 case "vouches": {
286 const [incomingPage, outgoingPage] = await Promise.all([
287 fetchPage(ctx, "sh.tangled.graph.listVouches", { subject: did, limit: PAGE_LIMIT }),
288 fetchPage(ctx, "sh.tangled.graph.listVouchesBy", { subject: did, limit: PAGE_LIMIT })
289 ]);
290 const [incoming, outgoing] = await Promise.all([
291 resolveVouches(ctx, incomingPage.items, "incoming"),
292 resolveVouches(ctx, outgoingPage.items, "outgoing")
293 ]);
294 const vouches = [...incoming, ...outgoing].sort(
295 (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
296 );
297 return {
298 tab,
299 vouches,
300 isSelf: parent.auth?.did === did,
301 profileHandle: handle
302 };
303 }
304 case "starred": {
305 const [page, viewerStarRkeys] = await Promise.all([
306 fetchPage(ctx, "sh.tangled.feed.listStarsBy", { subject: did, limit: PAGE_LIMIT }),
307 parent.auth?.did ? listStarRkeys(ctx, parent.auth.did) : undefined
308 ]);
309 return {
310 tab,
311 stars: await resolveStars(ctx, page.items, { viewerStarRkeys })
312 };
313 }
314 case "overview":
315 default: {
316 const [page, viewerStarRkeys] = await Promise.all([
317 fetchPage(ctx, "sh.tangled.repo.listRepos", { subject: did, limit: PAGE_LIMIT }),
318 parent.auth?.did ? listStarRkeys(ctx, parent.auth.did) : undefined
319 ]);
320
321 const pinnedKeys = parent.profile?.pinnedRepositories ?? [];
322 const byKey = new Map<string, ListItem>();
323 for (const item of page.items) {
324 const value = item.value as RepoRecord;
325 if (value.repoDid) byKey.set(value.repoDid, item);
326 byKey.set(item.uri, item);
327 }
328 const pinnedItems = pinnedKeys
329 .map((key) => byKey.get(key))
330 .filter((item): item is ListItem => item !== undefined);
331 const pinned = await Promise.all(
332 pinnedItems.map((item) => resolveRepoCard(ctx, item, handle, { viewerStarRkeys }))
333 );
334
335 return { tab: "overview" as const, overview: { pinned } };
336 }
337 }
338 } catch (cause) {
339 console.error("Page load error:", cause);
340 toHttpError(cause, "Could not load profile data");
341 }
342};